Перейти к основному содержимому

Интеграционные тесты на дедлоки и одновременные запросы

· 4 мин. чтения

Я в последнее время всё больше люблю писать интеграционные (API) тесты — запускаю половину приложения, но не привязан к UI. Это золотая середина между очень медленными end-to-end тестами и очень быстрыми unit-тестами. Рассмотрим особый случай таких тестов, которые используют заготовленные данные под каждый тест. 

Такие тесты приходится создавать, когда проект становится таких масштабов, что тесты с одной БД начинают конфликтовать между собой и становятся нестабильными. То список где-то постоянно растёт, то ID у ресурса требуется фиксированный а у нас autoincrement, то данные хочется удалить. 

Это особенно ярко видно в e2e тестах, где приходится управлять всем жизненным циклом данных что-бы тесты оставались рабочими. Управление всем циклом из создания-операции-удаления, вынуждает тесты делать зависимыми друг от друга, а значит становится невозможно запустить тест сам по себе.

Пример

Вот как выглядит моё решение этой проблемы..

use kurapov\tests\database\IsolatedDataIntegrationTestBase;

class UserIsolatedDataTest extends IsolatedDataIntegrationTestBase {
/**
* @test
*/
function postRemove_UserByManager() {
//$this->db->execute(file_get_contents(dirname(realpath(__FILE__)) . '/' . __CLASS__ . '/' . __FUNCTION__ . '.sql'));

$this->db->execute(
"INSERT INTO `user` (`id`, `email`, `password`) VALUES (1,'manager@kurapov.ee','553ae7da92f5505a92bbb8c9d47be76ab9f65bc2');
INSERT INTO `user` (`id`, `email`, `password`) VALUES (2,'user@kurapov.ee','f4542db9ba30f7958ae42c113dd87ad21fb2eddb');"
);

$this->loginAs('manager@kurapov.ee');
$result = $this->curlPOST($this->baseURL . 'User/remove', ['id' => 2]);
$this->assertNotContains('error', $result);
$this->assertEquals("{'status':'ok'}", $result, $result);
}
}

В данном случае при запуске теста, схема БД уже существует — она изолирована и чиста. Я лишь добавляю данные и делаю curl запрос. Я не проверяю итоговое состояние в БД в данном случае. Если SQL очень длинный, я могу вынести его в отдельный файл.

Поскольку этот тест одновременно занимается и сетевыми запросами (curlPOST функция) и подготовкой БД, то сам класс наследует написанный мною IntegrationTestBase и IsolatedDataIntegrationTestBase соответственно. Если бы я напрямую работал с функцией, без сетевых запросов, возможно я мог бы использовать DBUnit.

Пишу я в сыром SQL для mysql, абстрагированием (скажем с doctrine) и переключением на in-memory БД я не занимаюсь. Вместо этого, процесс подготовки у меня такой:

  • До запусков всех тестов прогоняются все миграции что-бы иметь up-to-date схему
  • В фазе setUp теста - удаляем тестовую БД
  • Копируем всю схему базы проекта в новую тестовую БД, без данных
  • Для конкретного теста запускаем SQL для добавления специфичных данных (Пользователи, данные, связки)
  • Запускаем сам тест, который делает сетевой запрос
  • В сетевом запросе - указываем дополнительный параметр, который в конфигурации переключает наш проект на тестовую БД (только для локального запуска)
  • Если тест падает - у нас в БД остаётся состояние тестовой БД, можно посмотреть

А так выглядит файлик подготавливающий тестовую БД..

namespace kurapov\tests\database;
use IntegrationTestBase;
use PDO;

class IsolatedDataIntegrationTestBase extends IntegrationTestBase {
const DEV_DBNAME = "myproject";
const TEST_DBNAME = "myproject-test";

public function setUp() {
$this->db = new \kurapov\Database(new \PDO(
'mysql:host=127.0.0.1;dbname=' . self::DEV_DBNAME . ";charset=utf8",
'root','',
[
\PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8',
\PDO::ATTR_PERSISTENT => true,
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION
]
));

$this->db->execute("DROP DATABASE IF EXISTS `" . self::TEST_DBNAME . "`;");
$this->duplicateDB();

$this->db = new \kurapov\Database(new \PDO(
'mysql:host=127.0.0.1;dbname=' . self::TEST_DBNAME . ";charset=utf8",
'root','',
[
\PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8',
\PDO::ATTR_PERSISTENT => true,
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION
]
));
}

public function tearDown() {
// $this->db->execute("DROP DATABASE `" . self::TEST_DBNAME . "`;");
}

protected function curlPost($url, $data, $useCookie = true) {
$data['isolated_db'] = self::TEST_DBNAME;
return parent::curlPost($url, $data, $useCookie);
}

protected function curlGET($url, $useCookie = true) {
return parent::curlGET($url . '&isolated_db=' . self::TEST_DBNAME, $useCookie);
}

private function duplicateDB() {
$tables = $this->db->execute("SHOW TABLES;");
$this->db->execute("CREATE DATABASE `" . self::TEST_DBNAME . "`;");

foreach ($tables as $table) {
$tableName = $table['Tables_in_' . self::DEV_DBNAME];
$this->db->execute("CREATE TABLE `" . self::TEST_DBNAME . "`.`$tableName` LIKE `" . self::DEV_DBNAME . "`.`$tableName`;");
}
}

public function copyTable($table) {
$this->db->execute(
"INSERT INTO `" . self::TEST_DBNAME . "`.$table
SELECT * FROM `" . self::TEST_DBNAME . "`.$table"
);
}
}

Итого

Я не форсирую использование таких тестов для всех случаев, а только там где мне кажется это необходимым. Большинство интеграционных тестов по-прежнему бегает на одной БД.

Достоинства

  • Тесты становятся стабильней, т.к. меньше зависимости друг от друга и данные не затираются/не добавляются
  • Изолированные тесты быстрей выполняются, чем цепочка запросов/тестов
  • Написание SQL для тестов начинает влиять на проектирование БД
  • В случае падения теста, можно посмотреть состояние тестовой БД именно в этом контексте
  • Потенциально можно расширить применение на e2e тесты либо распараллелить запуск используя отдельную БД на каждый тест или поток

Недостатки

  • Надо подготавливать для каждого теста свои данные в SQL это неприятно
  • Надо поддерживать этот SQL если вы измените схему и она затрагивает тест